package com.xueqiu.movieplaymanagersystem.utils.sensitiveUtils;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.Serializable;
import java.util.NavigableSet;

import static com.xueqiu.movieplaymanagersystem.constant.configConstant.DEFAULT_INITIAL_CAPACITY;

/**
 * 敏感词过滤器：以过滤速度优化为主。
 * 增加一个敏感词：{@link #putWord(String)}
 * 过滤一个句子：{@link #filter(String, char)}
 *
 * @author xueqiu
 */
@Slf4j
@Component
public class SensitiveFilter implements Serializable {
    private static final long serialVersionUID = 1L;

    /**
     * 类似HashMap的桶，比较稀疏。
     * 使用2个字符的hash定位。
     * 一个桶里，是可能存在多个节点的；因为我们自定义的找桶的hash算法不是很完美
     */
    protected SensitiveNode[] nodes = new SensitiveNode[DEFAULT_INITIAL_CAPACITY];

    public SensitiveFilter() {
    }

    /**
     * 加载敏感词词典；初始化过滤器
     * 词典要求：每行单个敏感词
     * 读取完成或者读取失败后，调用{@link BufferedReader#close()}
     * 读取过程中，不会抛出{@link IOException}
     *
     * @param reader 缓冲字符流
     */
    public SensitiveFilter(BufferedReader reader) {
        try {
            for (String line = reader.readLine(); line != null; line = reader.readLine()) {
                putWord(line);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                reader.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 存入单个敏感词
     * 规则：{@link String#trim()}操作后，长度 < 2，丢弃该词
     * 此方法（构建）并不是主要的性能优化点
     *
     * @param word 敏感词
     */
    public boolean putWord(String word) {
        // 长度小于2的丢弃
        if (word == null || word.trim().length() < 2) {
            return false;
        }
        // 两个ascii字符的丢弃；正则表达式：\w == 1个字母或数字
        if (word.length() == 2 && word.matches("\\w\\w")) {
            return false;
        }

        StringPointer stringPointer = new StringPointer(word.trim());
        // 计算头两个字符的hash
        int hash = stringPointer.nextTwoCharHash(0);
        // 计算头两个字符的mix
        int mix = stringPointer.nextTwoCharMix(0);
        // 计算stringPointer在桶中所处的索引
        /*
         DEFAULT_INITIAL_CAPACITY - 1 的二进制表示为 0000 0000 0000 0001 1111 1111 1111 1111
         通过将 hash 与 (DEFAULT_INITIAL_CAPACITY - 1) 进行'与'位运算，
         可以保证 index 的值在 0 到 DEFAULT_INITIAL_CAPACITY - 1 之间。

         这是因为与位运算的结果会将 hash 的二进制表示的最高位变为0，其他位保持不变，从而得到一个范围在 0 到 DEFAULT_INITIAL_CAPACITY - 1 之间的值。

         由于 nodes[] 是一个数组，它的索引从0开始，
         因此 index 可以直接作为 nodes[] 数组的索引来访问特定位置的元素。
         */
        int index = hash & (DEFAULT_INITIAL_CAPACITY - 1);

        // 从桶里拿第一个节点
        SensitiveNode node = nodes[index];
        if (node == null) {
            // 如果没有节点，则放进去一个节点
            node = new SensitiveNode(mix);
            // 并给节点添加词；一个节点可以被添加许多“开头一致的”词
            node.words.add(stringPointer);
            // 把节点放入桶里；一个桶只能添加一个节点；但是可以在这个桶上伸出链的形式添加更多的节点
            nodes[index] = node;
        } else {
            // 如果已经有节点（1个或多个），找到正确的节点
            SensitiveNode parent = null;
            while (node != null) {
                // 匹配节点
                if (node.headTwoCharMix == mix) {
                    node.words.add(stringPointer);
                    break;
                }
                parent = node;
                node = node.next;
            }
            // 如果匹配到最后仍然不成功，则追加一个节点
            if (node == null) {
                new SensitiveNode(mix, parent).words.add(stringPointer);
            }
        }
        return true;
    }

    /**
     * 对句子进行敏感词过滤
     * 如果无敏感词返回输入的sentence对象，即可以用下面的方式判断是否有敏感词：
     * String result = filter.filter(sentence, '*');
     * if(result != sentence){
     * 有敏感词
     * }
     *
     * @param sentence    被过滤的句子
     * @param replaceChar 敏感词的替换字符
     * @return 过滤后的句子
     */
    public String filter(String sentence, char replaceChar) {
        StringPointer stringPointer = new StringPointer(sentence);
        boolean replaced = false;

        // 匹配的起始位置
        int i = 0;
        // 匹配结束条件是，指针指向被判断的句子的倒数第2个索引；只有<=，才能把最后2个字符判断
        while (i <= stringPointer.length - 2) {
            /*
             * 移动到下一个匹配位置的步进：int step
             * 如果一次循环中未匹配任何词，则step为1，
             * 如果匹配了一个词，那么step是匹配的词长度。这样做方便快速判断，被判断的句子的，其他部分
             */
            int step = 1;
            // 计算此位置开始2个字符的hash
            int hash = stringPointer.nextTwoCharHash(i);
            /*
             * 根据hash值，获取桶中的第一个节点，然后挨个往下撸
             * 往下撸的原因是，真正匹配的节点可能不是桶的第一个节点
             */
            SensitiveNode node = nodes[hash & (DEFAULT_INITIAL_CAPACITY - 1)];
            /*
             * 如果非敏感词，node基本为null。
             * 这一步大幅提升效率
             */
            if (node != null) {
                /*
                 * 如果能拿到第一个节点，
                 * 才计算mix（mix相同表示2个字符相同）。
                 * mix的意义和HashMap先hash再equals的equals部分类似。
                 */
                int mix = stringPointer.nextTwoCharMix(i);
                /*
                 * 循环所有的节点，如果非敏感词，
                 * mix相同的概率非常低，提高效率
                 */
                outer:
                for (; node != null; node = node.next) {
                    /*
                     * 对于一个节点，先根据头2个字符判断是否属于这个节点。
                     * 如果属于这个节点，看这个节点的词库是否命中。
                     * 此代码块中访问次数已经很少，不是优化重点
                     */
                    if (node.headTwoCharMix == mix) {
                        /*
                         * 查出比剩余sentence小的最大的词。
                         * 例如剩余sentence为"色情电影哪家强？"，
                         * 这个节点含三个词从小到大为："色情"、"色情电影"、"色情信息"。
                         * 则从“色情电影”开始按treeSet中的顺序向后匹配
                         */
                        NavigableSet<StringPointer> desSet = node.words.headSet(stringPointer.substring(i), true);
                        /*
                        用于返回一个 NavigableSet<E> 子集，该子集包含小于（或等于，根据 inclusive 参数的值）给定元素 toElement 的所有元素。
                        使用场景：
                            这个方法在需要获取 TreeSet 中小于（或等于）给定元素的所有元素时非常有用。
                            可以用于按照自然顺序或自定义顺序获取 TreeSet 中的元素。

                        方便了什么操作：
                            这个方法方便了对 TreeSet 中元素的范围查询操作。通过指定 toElement 和 inclusive 参数，可以轻松地获取满足条件的子集。
                            可以使用返回的 NavigableSet<E> 子集进行进一步的操作，比如遍历、查找、删除等。

                         */
                        for (StringPointer word : desSet.descendingSet()) {
                            /*
                            用于返回一个与当前 NavigableSet<E> 相反顺序的 NavigableSet<E> 子集。
                            {1, 2, 3, 4, 5}
                            descendingSet()后输出: [5, 4, 3, 2, 1]
                             */
                            /*
                            node.headTwoCharMix == mix判断通过后，
                            还需要进行nextStartsWith()判断
                            这是因为，假设剩余sentence为"色情的信息哪里有？"，
                            如果判断通过的节点只因为包含"色情电影"一个词，恰巧因为“色情”2个字符开头而通过了node.headTwoCharMix == mix
                            如果不加nextStartsWith()，就会把“色情电影”这四个字符长度当作需要替换的长度
                            "****息哪里有？"

                            但实际上需要替换的长度是“色情”2个字符
                            "**的信息哪里有？"
                             */
                            if (stringPointer.nextStartsWith(i, word)) {
                                // 匹配成功，将匹配的部分，用replace制定的内容替代
                                stringPointer.fill(i, i + word.length, replaceChar);
                                // 跳过已经替代的部分
                                step = word.length;
                                // 标示有替换
                                replaced = true;
                                // 跳出循环（然后是while循环的下一个位置）
                                break outer;
                            }
                        }
                    }
                }
            }
            // 移动到下一个匹配位置
            i += step;
        }
        // 如果没有替换，直接返回入参（节约String的构造copy）
        if (replaced) {
            return stringPointer.toString();
        } else {
            return sentence;
        }
    }
}
